Testing

come scrivere del codice corretto e tornare a godersi la vita

Un codice corretto è un codice senza bug e senza errori

bug

Un codice ha un bug quando si comporta diversamente da quanto scritto nella sua documentazione.

Nella documentazione va inclusa la documentazione esterna (manuale), quella interna (docstrings) e quella implicita (nome delle funzione ed argomenti).

Sono in generale considerati bug anche i commenti ed i nomi delle variabili che non corrispondono a cosa sta facendo il codice.

la funzione più buggata della storia

SPECIFICA: scrivi una funzione che integri una parabola fra due numeri

def moltiplicazione(nome, cognome):
    """questa funzione divide due numeri"""
    # eseguo la sottrazione
    esponente = nome[cognome]
    return esponente

Il codice è corretto ed esegue bene, ma usarla in un codice reale sarebbe un suicidio!

errore

un codice è errato quando si comporta diversamente da quello che la logica per cui è stato scritto prescrive

Ad esempio una funzione di ordinamento che non ordina, oppure in alcuni casi particolari non ordina bene.

buona documentazione

prendete spunto dalla documentazione di numpy. non si può chiedere molto altro.

In [8]:
import numpy
print("\n".join(numpy.linalg.eig.__doc__.splitlines()[:20]))
    Compute the eigenvalues and right eigenvectors of a square array.

    Parameters
    ----------
    a : (..., M, M) array
        Matrices for which the eigenvalues and right eigenvectors will
        be computed

    Returns
    -------
    w : (..., M) array
        The eigenvalues, each repeated according to its multiplicity.
        The eigenvalues are not necessarily ordered. The resulting
        array will be of complex type, unless the imaginary part is
        zero in which case it will be cast to a real type. When `a`
        is real the resulting eigenvalues will be real (0 imaginary
        part) or occur in conjugate pairs

    v : (..., M, M) array
In [17]:
print("\n".join(numpy.linalg.eig.__doc__.splitlines()[20:39]))
        The normalized (unit "length") eigenvectors, such that the
        column ``v[:,i]`` is the eigenvector corresponding to the
        eigenvalue ``w[i]``.

    Raises
    ------
    LinAlgError
        If the eigenvalue computation does not converge.

    See Also
    --------
    eigvals : eigenvalues of a non-symmetric array.

    eigh : eigenvalues and eigenvectors of a symmetric or Hermitian
           (conjugate symmetric) array.

    eigvalsh : eigenvalues of a symmetric or Hermitian (conjugate symmetric)
               array.

In [18]:
print("\n".join(numpy.linalg.eig.__doc__.splitlines()[39:60]))
    Notes
    -----

    .. versionadded:: 1.8.0

    Broadcasting rules apply, see the `numpy.linalg` documentation for
    details.

    This is implemented using the _geev LAPACK routines which compute
    the eigenvalues and eigenvectors of general square arrays.

    The number `w` is an eigenvalue of `a` if there exists a vector
    `v` such that ``dot(a,v) = w * v``. Thus, the arrays `a`, `w`, and
    `v` satisfy the equations ``dot(a[:,:], v[:,i]) = w[i] * v[:,i]``
    for :math:`i \in \{0,...,M-1\}`.

    The array `v` of eigenvectors may not be of maximum rank, that is, some
    of the columns may be linearly dependent, although round-off error may
    obscure that fact. If the eigenvalues are all different, then theoretically
    the eigenvectors are linearly independent. Likewise, the (complex-valued)
    matrix of eigenvectors `v` is unitary if the matrix `a` is normal, i.e.,
In [11]:
print("\n".join(numpy.linalg.eig.__doc__.splitlines()[60:80]))
    if ``dot(a, a.H) = dot(a.H, a)``, where `a.H` denotes the conjugate
    transpose of `a`.

    Finally, it is emphasized that `v` consists of the *right* (as in
    right-hand side) eigenvectors of `a`.  A vector `y` satisfying
    ``dot(y.T, a) = z * y.T`` for some number `z` is called a *left*
    eigenvector of `a`, and, in general, the left and right eigenvectors
    of a matrix are not necessarily the (perhaps conjugate) transposes
    of each other.

    References
    ----------
    G. Strang, *Linear Algebra and Its Applications*, 2nd Ed., Orlando, FL,
    Academic Press, Inc., 1980, Various pp.

    Examples
    --------
    >>> from numpy import linalg as LA

    (Almost) trivial example with real e-values and e-vectors.
In [12]:
print("\n".join(numpy.linalg.eig.__doc__.splitlines()[80:100]))
    >>> w, v = LA.eig(np.diag((1, 2, 3)))
    >>> w; v
    array([ 1.,  2.,  3.])
    array([[ 1.,  0.,  0.],
           [ 0.,  1.,  0.],
           [ 0.,  0.,  1.]])

    Real matrix possessing complex e-values and e-vectors; note that the
    e-values are complex conjugates of each other.

    >>> w, v = LA.eig(np.array([[1, -1], [1, 1]]))
    >>> w; v
    array([ 1. + 1.j,  1. - 1.j])
    array([[ 0.70710678+0.j        ,  0.70710678+0.j        ],
           [ 0.00000000-0.70710678j,  0.00000000+0.70710678j]])

    Complex-valued matrix with real e-values (but complex-valued e-vectors);
    note that a.conj().T = a, i.e., a is Hermitian.

In [16]:
print("\n".join(numpy.linalg.eig.__doc__.splitlines()[100:120])) #fine
    >>> a = np.array([[1, 1j], [-1j, 1]])
    >>> w, v = LA.eig(a)
    >>> w; v
    array([  2.00000000e+00+0.j,   5.98651912e-36+0.j]) # i.e., {2, 0}
    array([[ 0.00000000+0.70710678j,  0.70710678+0.j        ],
           [ 0.70710678+0.j        ,  0.00000000+0.70710678j]])

    Be careful about round-off error!

    >>> a = np.array([[1 + 1e-9, 0], [0, 1 - 1e-9]])
    >>> # Theor. e-values are 1 +/- 1e-9
    >>> w, v = LA.eig(a)
    >>> w; v
    array([ 1.,  1.])
    array([[ 1.,  0.],
           [ 0.,  1.]])

    

purezza delle funzioni

scrivete funzioni pure.

Una funzione è chiamata pura se, a parità di input, ritorna lo stesso output.

Questo vi permette di essere sicuri che una volta verificata la correttezza di una funzione, questo risultato non cambi.

Questo è anche uno dei motivi per cui le variabili globali sono considerate cattiva pratica

tipi di test possibili

Riprendiamo il codice della seconda lezione sugli automi cellulari.

Vogliamo testare la nostra funzione per verificare che si comporti in modo corretto.

Che test possiamo pensare di fare?

  • test di avanzamento: le modifiche che ho introdotto nel mio codice fanno quello che penso

  • test di regressione: le modifiche che ho introdotto nel mio codice non cambiano come funziona il resto del codice

  • test positivi: il mio codice mi da il riultato che mi aspetto

  • test negativi: il mio codice fallisce quando non rispetto le richieste

strategie di test possibili

  • test informale
  • unit test (test aneddotici)
  • property testing

test informali

Quando scriviamo una funzione, di solito testiamo se funziona senza problemi.

Questo tipo di test è ovviamente necessario, ma ne incontriamo subito i limiti.

In [33]:
def inc(x):
    return x + 1

assert inc(3)==4
assert inc(5)==4
assert inc(6)==4
---------------------------------------------------------------------------
AssertionError                            Traceback (most recent call last)
<ipython-input-33-a12389ee159e> in <module>()
      3 
      4 assert inc(3)==4
----> 5 assert inc(5)==4
      6 assert inc(6)==4

AssertionError: 

test aneddotici (unit testing)

mi salvo in uno script i test che ho effettuato con degli assert.

Ogni volta che faccio una modifica al mio codice lancio i test per vedere che tutto funzioni.

Se osservo un bug, inserisco un nuovo test che mi garantisca che quel bug non si ripresenti.

in generale voglio almeno un esempio che mi mostri il caso tipico d'uso, più un esempio per ogni caso limite.

Immaginate di scrivere una funzione che vi metta in ordine una lista. Volete testare:

  1. una lista fuori ordine, come [1, 3, 2], che dia il risultato [1, 2, 3]
  2. una lista vuota dia in uscita una lista vuota
  3. una lista già in ordine, come [1, 2, 3], dia come risultato la stessa lista [1, 2, 3]

e così via, ripetendo per diverse liste.

un'ottima libreria per lo unit testing è pytest.

Pytest è un comando da shell che ricerca le funzioni chiamate test_qualcosa e le esegue tutte, riportandoci i risultati

In [34]:
%%file test_prova.py
def inc(x):
    return x + 1

def test_answer_1():
    assert inc(3) == 5
    
def test_answer_2():
    assert inc(7) == 7
Overwriting test_prova.py
In [32]:
!pytest test_prova.py
============================= test session starts ==============================
platform linux -- Python 3.6.0, pytest-3.0.6, py-1.4.32, pluggy-0.4.0
rootdir: /home/enrico, inifile: 
plugins: xonsh-0.5.6, hypothesis-3.6.1
collected 2 items 

test_prova.py FF

=================================== FAILURES ===================================
________________________________ test_answer_1 _________________________________

    def test_answer_1():
>       assert inc(3) == 5
E       assert 4 == 5
E        +  where 4 = inc(3)

test_prova.py:5: AssertionError
________________________________ test_answer_2 _________________________________

    def test_answer_2():
>       assert inc(7) == 7
E       assert 8 == 7
E        +  where 8 = inc(7)

test_prova.py:8: AssertionError
=========================== 2 failed in 0.03 seconds ===========================

Potrei eseguire il codice di test anche manualmente, ma questo avrebbe diversi svantaggi:

  1. dovrei eseguire a mano tutte le singole funzioni di test (invece che farle scoprire ed eseguire da pytest)
  2. alla prima eccezione l'intera procedura si interromperebbe, obbligandomi ad avere una visione parziale. Pytest mi visualizza tutte gli errori
  3. il risultato dell'assert sarebbe molto meno chiaro, mentre pytest lo decora in modo comprensibile

Pytest può inoltre controllare anche se ci aspettiamo un'eccezione, cosa molto più scomoda con il codice normale

In [ ]:
import pytest

def test_zero_division():
    with pytest.raises(ZeroDivisionError):
        1 / 0

Pytest automatizza già notevolmente la nostra procedura di test, ma dobbiamo scrivere ancora a mano un gran numero di test diversi e simili fra di loro.

Dobbiamo ancora trovare un modo per migliorare: l'ideale sarebbe che il computer generasse i test al posto nostro!

Questo non è possibile in senso letterale, ma ci possiamo arrivare abbastanza vicini.

Test basati sulle proprietà

Vado a generalizzare i test che ho scritto in modo aneddotico

Nello unit test:

  • per ciascun test:
    • per ciascun caso:
      1. specifico l'input
      2. specifico il risultato atteso

Nel property test:

  • specifico il tipo di dato in input
  • per ciascun test:
    1. specifico l'invarianza associata a quel test

la libreria che uso genererà in maniera casuale i dati in input secondo le regole che ho specificato, li lancerà contro la funzione e cercherà di romperla in tutti i modi.

Se riesce a violare una proprietà, semplifica l'esempio fino a trovare l'esempio più piccolo possibile che ancora vìola quella proprietà, e ce lo restituisce.

Il property based testing non rimpiazza lo unit testing.

Lo estende e lo rende più potente, mentre allo stesso tempo riduce la quantità di codice triviale che dovete scrivere.

Ovviamente per usarlo dovrete pensare di più, ma non sareste qui se aveste paura di pensare.

La libreria che useremo per i property test si chiama hypothesis.

Hypotesis si appoggia a librerie come pytest per il testing, ma genera in modo automatico i test tramite le strategie, che definiscono come dei dati casuali debbano essere passati alla libreria di test.

In [20]:
%%file test_prova.py
from hypothesis import given
import hypothesis.strategies as st

def inc(x):
    if x==5:
        return 0
    return x + 1

def dec(x):
    return x - 1

@given(value=st.integers())
def test_answer_1(value):
    print(value)
    assert dec(inc(value)) == value
    
@given(value=st.integers())
def test_answer_2(value):
    assert dec(inc(value)) == inc(dec(value))
Overwriting test_prova.py
In [21]:
!pytest test_prova.py
============================= test session starts ==============================
platform linux -- Python 3.6.0, pytest-3.0.6, py-1.4.32, pluggy-0.4.0
rootdir: /home/enrico, inifile: 
plugins: xonsh-0.5.6, hypothesis-3.6.1
collected 2 items 

test_prova.py .F

=================================== FAILURES ===================================
________________________________ test_answer_2 _________________________________

    @given(value=st.integers())
>   def test_answer_2(value):

test_prova.py:18: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 
/usr/local/lib/python3.6/site-packages/hypothesis/core.py:524: in wrapped_test
    print_example=True, is_final=True
/usr/local/lib/python3.6/site-packages/hypothesis/executors.py:58: in default_new_style_executor
    return function(data)
/usr/local/lib/python3.6/site-packages/hypothesis/core.py:111: in run
    return test(*args, **kwargs)
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

value = 5

    @given(value=st.integers())
    def test_answer_2(value):
>       assert dec(inc(value)) == inc(dec(value))
E       assert -1 == 5
E        +  where -1 = dec(0)
E        +    where 0 = inc(5)
E        +  and   5 = inc(4)
E        +    where 4 = dec(5)

test_prova.py:19: AssertionError
---------------------------------- Hypothesis ----------------------------------
Falsifying example: test_answer_2(value=5)
====================== 1 failed, 1 passed in 0.15 seconds ======================

Per arrivare a scrivere i test di proprietà non dobbiamo necessariamente partire da zero, ma possiamo costruirli sulla base degli unit test, usando la strategia just

In [44]:
%%file test_prova.py
def inc(x):
    return x + 1

def test_answer_1a():
    assert inc(3) == 4
    

from hypothesis import given
import hypothesis.strategies as st

@given(x=st.just(3))
def test_answer_1b(x):
    assert inc(x) == x+1
    
@given(x=st.floats())
def test_answer_1c(x):
    assert inc(x) == x+1
Overwriting test_prova.py
In [45]:
!pytest test_prova.py
============================= test session starts ==============================
platform linux -- Python 3.6.0, pytest-3.0.6, py-1.4.32, pluggy-0.4.0
rootdir: /home/enrico, inifile: 
plugins: xonsh-0.5.6, hypothesis-3.6.1
collected 3 items 

test_prova.py ..F

=================================== FAILURES ===================================
________________________________ test_answer_1c ________________________________

    @given(x=st.floats())
>   def test_answer_1c(x):

test_prova.py:16: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 
/usr/local/lib/python3.6/site-packages/hypothesis/core.py:524: in wrapped_test
    print_example=True, is_final=True
/usr/local/lib/python3.6/site-packages/hypothesis/executors.py:58: in default_new_style_executor
    return function(data)
/usr/local/lib/python3.6/site-packages/hypothesis/core.py:111: in run
    return test(*args, **kwargs)
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

x = nan

    @given(x=st.floats())
    def test_answer_1c(x):
>       assert inc(x) == x+1
E       assert nan == (nan + 1)
E        +  where nan = inc(nan)

test_prova.py:17: AssertionError
---------------------------------- Hypothesis ----------------------------------
Falsifying example: test_answer_1c(x=nan)
====================== 1 failed, 2 passed in 0.62 seconds ======================

Come dicevamo, la matematica sul computer è difficile.

Se volessimo ignorare questo caso, potremmo usare la direttiva assume, che impone il rispetto di una condizione agli esempi forniti.

In [ ]:
from math import isnan
from hypothesis import assume

@given(x=st.floats())
def test_answer_1c(x):
    assume(not isnan(x))
    assert inc(x) == x+1

(Alcuni) Pattern di proprietà da testare

gli esempi di proprietà sono basati su quelli presentati in questo sito

proprietà commutativa

esistenza di una inversa

leggi di conservazione

idempotenza

induzione

difficile da dimostrare, facile da verificare

test dell'oracolo

Test Driven Development

Nel test driven development, il codice ed i test sono scritti insieme, a partire dalle specifiche. Esistono molte varianti di questo concetto, ma l'idea di base è che non bisogna aspettare di scrivere tutto il codice per iniziare a scrivere i test. Nei casi più estremi si possono addirittura scrivere i test prima ancora del codice!

Nel design di architetture complicate, in cui il design è necessariamente top-down, è una pratica insostituibile.

Automi cellulari, take 2

Riprendiamo l'esempio di ieri, cercando di lavorare sulla base di test possibili.

Usiamo quindi l'approcci opposto rispetto a ieri, ovvero un top-down.

Nell'approccio top down svilupperemo il nostro codice fingendo che funzioni tutto bene, poi lo faremo diventare realtà:

  • partiamo dalla funzione "finale", richiamando le altre come se esistessero già;
  • implementeremo ciascuna funzione come uno "stub", ovvero una versione fasulla, che però ci permette di eseguire il codice;
  • scriviamo dei test che rappresentino le proprietà che ci aspettiamo dalle nostre funzioni reali
  • rimpiazziamo gli stub con funzioni che approssimino sempre meglio il reale
In [3]:
def simulazione(nsteps):
    stato_iniziale = genera_stato()
    stati = [stato_iniziale]
    for i in range(nsteps):
        vecchio_stato = stati[-1]
        nuovo_stato = evolvi(vecchio_stato)
        stati.append(nuovo_stato)
    return stati

notate come non abbia ancora definito in cosa consista la funzione genera_stato e la funzione evolvi.

Ora vado ad implementare degli stubs.

Quali sono le versioni più semplici che possono pensare per far eseguire il mio codice?

Partiamo dall'idea di lavorare su stringhe come ieri (non è obbligatorio, è solo una possibilità)

In [4]:
def genera_stato():
    return "stringa"

def evolvi(stato):
    return stato
In [5]:
simulazione(5)
Out[5]:
['stringa', 'stringa', 'stringa', 'stringa', 'stringa', 'stringa']

Sembra banale, ma ora abbiamo un codice che fa qualcosa, e possiamo migliorarlo incrementalmente invece di cercare di ideare tutto insieme!

Questo approccio ci permette di dividere il problema in sottopassaggi più digeribili, ma richiede di fare qualche assunzione.

Iniziamo ore a scrivere i nostri test.

Partiamo dalla generazione del nostro stato.

Che proprietà vogliamo che abbia?

Ad esempio, potremmo richiedere che i valori possibili siano soltato '.' ed '0'

In [6]:
%%file test_prova.py

def genera_stato():
    return "stringa"

def evolvi(stato):
    return stato

def simulazione(nsteps):
    stato_iniziale = genera_stato()
    stati = [stato_iniziale]
    for i in range(nsteps):
        vecchio_stato = stati[-1]
        nuovo_stato = evolvi(vecchio_stato)
        stati.append(nuovo_stato)
    return stati

########################################################

def test_generazione():
    stato = genera_stato()
    assert set(stato) == {'.', '0'}
Writing test_prova.py
In [7]:
!pytest test_prova.py
============================= test session starts ==============================
platform linux -- Python 3.6.0, pytest-3.0.6, py-1.4.32, pluggy-0.4.0
rootdir: /home/enrico/lavoro/DataProgrammingCourse, inifile: 
plugins: xonsh-0.5.7, hypothesis-3.6.1
collected 1 items 

test_prova.py F

=================================== FAILURES ===================================
_______________________________ test_generazione _______________________________

    def test_generazione():
        stato = genera_stato()
>       assert set(stato) == {'.', '0'}
E       assert {'a', 'g', 'i...'r', 's', ...} == {'.', '0'}
E         Extra items in the left set:
E         'r'
E         'i'
E         'n'
E         's'
E         't'
E         'a'
E         'g'
E         Extra items in the right set:
E         '.'
E         '0'
E         Use -v to get the full diff

test_prova.py:21: AssertionError
=========================== 1 failed in 0.02 seconds ===========================
In [12]:
%%file test_prova.py

def genera_stato():
    return "....00......"

def evolvi(stato):
    return stato

def simulazione(nsteps):
    stato_iniziale = genera_stato()
    stati = [stato_iniziale]
    for i in range(nsteps):
        vecchio_stato = stati[-1]
        nuovo_stato = evolvi(vecchio_stato)
        stati.append(nuovo_stato)
    return stati

########################################################

def test_generazione():
    stato = genera_stato()
    assert set(stato) == {'.', '0'}
Overwriting test_prova.py
In [13]:
!pytest test_prova.py
============================= test session starts ==============================
platform linux -- Python 3.6.0, pytest-3.0.6, py-1.4.32, pluggy-0.4.0
rootdir: /home/enrico/lavoro/DataProgrammingCourse, inifile: 
plugins: xonsh-0.5.7, hypothesis-3.6.1
collected 1 items 

test_prova.py .

=========================== 1 passed in 0.00 seconds ===========================

Il nostro test funziona!

Certo, non abbiamo una funzione che genera uno stato interessante, ma intanto genera uno stato valido!

I metodi TDD ci evitano anche di cadere nel trucco della sovraingegnerizzazione.

Non aggiungiamo nuove features al codice finché non ci servono!

La prossima richiesta che potremmo mettere è che non solo contenga solo '.' ed '0', ma che ci sia soltanto uno '0'.

In [14]:
%%file test_prova.py

def genera_stato():
    return "....00......"

def evolvi(stato):
    return stato

def simulazione(nsteps):
    stato_iniziale = genera_stato()
    stati = [stato_iniziale]
    for i in range(nsteps):
        vecchio_stato = stati[-1]
        nuovo_stato = evolvi(vecchio_stato)
        stati.append(nuovo_stato)
    return stati

########################################################

def test_generazione():
    stato = genera_stato()
    assert set(stato) == {'.', '0'}
    
def test_generazione():
    stato = genera_stato()
    num_of_0 = sum(1 for i in stato if i=='0')
    assert num_of_0 == 1
Overwriting test_prova.py
In [15]:
!pytest test_prova.py
============================= test session starts ==============================
platform linux -- Python 3.6.0, pytest-3.0.6, py-1.4.32, pluggy-0.4.0
rootdir: /home/enrico/lavoro/DataProgrammingCourse, inifile: 
plugins: xonsh-0.5.7, hypothesis-3.6.1
collected 1 items 

test_prova.py F

=================================== FAILURES ===================================
_______________________________ test_generazione _______________________________

    def test_generazione():
        stato = genera_stato()
        num_of_0 = sum(1 for i in stato if i=='0')
>       assert num_of_0 == 1
E       assert 2 == 1

test_prova.py:26: AssertionError
=========================== 1 failed in 0.02 seconds ===========================
In [16]:
%%file test_prova.py

def genera_stato():
    return ".....0......"

def evolvi(stato):
    return stato

def simulazione(nsteps):
    stato_iniziale = genera_stato()
    stati = [stato_iniziale]
    for i in range(nsteps):
        vecchio_stato = stati[-1]
        nuovo_stato = evolvi(vecchio_stato)
        stati.append(nuovo_stato)
    return stati

########################################################

def test_generazione():
    stato = genera_stato()
    assert set(stato) == {'.', '0'}
    
def test_generazione():
    stato = genera_stato()
    num_of_0 = sum(1 for i in stato if i=='0')
    assert num_of_0 == 1
Overwriting test_prova.py
In [17]:
!pytest test_prova.py
============================= test session starts ==============================
platform linux -- Python 3.6.0, pytest-3.0.6, py-1.4.32, pluggy-0.4.0
rootdir: /home/enrico/lavoro/DataProgrammingCourse, inifile: 
plugins: xonsh-0.5.7, hypothesis-3.6.1
collected 1 items 

test_prova.py .

=========================== 1 passed in 0.01 seconds ===========================
In [ ]: